带你深入理解CPP20中模块的优势

模块是C++ 20的四大功能之一:概念,范围,协程和模块。模块还是有很多前景:编译时改进了宏隔离,废除头文件等极其不优美的解决方法。我会通过一段浅显易懂的代码带你解析模块的优势,考虑到大家普遍对C++11比较熟悉,示例中的代码无特殊说明默认是C++11,因为11大家都懂,所以11的代码方便暴露出问题,一上手就是20可能对其优势就无感啦。

执行一段大家都懂的Demo

毫无疑问,当然得从“Hello World”开始了!

// helloWorld.cpp
#include <iostream>
int main() {
    std::cout << "Hello World" << std::endl;
}

编译一下,我们来创建可执行文件helloWorld,屏幕截图中的100和12928代表字节数,如下图所示。

编译

经典的构建过程

我们都知道经典的构建过程包括三个步骤:预处理,编译和链接。下面就分别详细展开介绍一下吧!

预处理

预处理器处理预处理器指令“#include”和“#define”。预处理程序将#inlude指令替换为相应的头文件,并替换宏(#define)。当然有些指令,例如#if,#else,#elif,#ifdef,#ifndef,和#endif中的部分可以被替换排除。

我们通过使用GCC上的-E或Windows上/E的编译器参数,可以观察到源码的替换过程。

预处理步骤的输出超过50万字节。所以你就不要责怪GCC慢了,因为其他编译器也很冗长,当然你可以用CompilerExplorer实际测试一下。

预处理器的输出是编译器的输入,那么我们接下来看看代码展开后是如何编译成汇编代码的?

汇编

编译是在预处理器的每个输出上单独执行的,编译器解析C++源代码并将其转换为汇编代码。生成的文件称为目标文件,是二进制形式的已编译代码。目标文件可以引用没有定义的符号。可以将目标文件归档到文件中,以供以后复用,我们称这些存档为静态库。

编译器生成的对象或转换单元是链接器的输入,接下来我们看看链接器。

链接器

链接器的输出可以是可执行文件,也可以是静态或共享库。链接程序的工作是将对引用的引用解析为未定义的符号,这些符号需要在目标文件或库中定义。

以上三步的构建过程是从C继承过来的,当然C++还有翻译单元。我们的程序由一个或多个翻译单元组成,当同一名称在不同的翻译单元中有两个不同的定义时,将发生链接器错误,这个问题在20中已经解决啦,且听下解!

构建过程中存在的问题

在经典构建过程中可能会遇到如下一些缺陷。在C++20 中,模块被引入的替代方法,是可以完全克服这些问题。

预处理器重复处理问题

首先预处理程序将#inlude指令替换为相应的头文件。我来更改一下上面的helloWorld.cpp程序以使得头重复定义出来。我重构了程序,并添加了两个源文件hello.cpp和world.cpp。源文件hello.cpp提供功能“hello”,源文件world.cpp提供功能“world”。两个源文件都包含相应的头。重构意味着该程序执行与先前程序helloWorld.cpp相同的操作。 虽然分开重构了,但是内部结构发生了变化,如下:

hello.cpp和hello.h
// hello.cpp
#include "hello.h"
void hello() {
    std::cout << "hello ";
}
// hello.h
#include <iostream>
void hello();
world.cpp和world.h
// world.cpp
#include "world.h"
void world() {
    std::cout << "world";
}
// world.h
#include <iostream>
void world();
helloWorld2.cpp
// helloWorld2.cpp
#include <iostream>
#include "hello.h"
#include "world.h"
int main() {
    hello(); 
    world(); 
    std::cout << std::endl;
}

构建和执行程序:

编译

这里就有一个问题了:预处理程序在每个源文件上运行,意味着头文件在每个转换单元中是包含3次。因此每个源文件被编译输出超过五十万行,如下所示。

编译出3次

显然,重复预处理的做法会增加编译时间。不过在C++20中你只需要导入模块一次,根本就不会造成重复预处理操作。

与预处理器宏的隔离问题

根据C++发展的一个方向,那就是应该摆脱预处理器宏。为什么呢?使用宏只是文本替换,不包括任何C ++语义。预处理器宏带来了很多麻烦,例如包含宏的顺序问题,宏名称冲突等。

有如下webcolors.h和productinfo.h:

// webcolors.h 
#define RED 0xFF0000
// productinfo.h
#define RED   0

当源文件client.cpp包含这两个标头时,宏RED的值取决于标头包含的顺序,所以有时会出错。但是,导入模块的顺序是没有区别。

符号的多重定义

ODR代表“定义规则”,其功能如下:

一个功能在任何翻译单元中的定义不得超过一个。

一个函数在程序中的定义不能超过一个。

具有外部链接的内联函数可以在多个翻译中进行定义。定义必须满足每个定义必须相同的要求。

让我们看看,当我来违反上面一个定义规则时,看看链接器输出是什么。以下代码示例包含两个头文件header.h和header2.h。主程序包含两次头文件header.h,所以就打破了第一个定义规则,因为其中包括了func的两个定义。

// header.h
void func() {}
// header2.h
#include "header.h"
// main.cpp
#include "header.h"
#include "header2.h"
int main() {}

链接器报错有多个func的定义:

报错

之前我们是用比较傻的解决方法,在标头周围放置一个包含保护来解决此问题:

// header.h
#ifndef FUNC_H
#define FUNC_H
void func(){}
#endif

但是,在带有模块中包含有相同符号是几乎不可能滴!

C++20模块的用法

为了照顾部分还没有接触过C++20的朋友,特意新增加了模块的用法部分。那么我们要怎么创建一个 Module 呢?20中引入了新的关键字 import、module,并使用保留关键字 export 来导入、定义和导出 Module,具体示例如下:

// hello_world.cpp
export module demos.hello.world;
export auto get_start()
{
  return "Hello C++ Modules!";
}
// main.cpp
import demos.hello.world;
import <iostream>;
int main()
{
  std::cout << get_start() << std::endl;
}

以上就是一个 C++20 Modules版的Hello World,方便部分读者调试扩展。本文的目的是通过C++11标准的语法来暴露问题,来充分体现出C++20的优势,这样方能体现C++标准迭代的良好延续性,而非一上来就介绍C++20的语法糖,这部分需要读者自行了解。

模块优势总结

以上我用最简单的代码带你深入浅出的理解了C++11存在的问题,在20中模块都给出了解决方案。最后那就做个模块优点的总结归纳:

模块仅导入一次,不会造成重复编译输出。

导入模块的顺序没有区别。

模块中避免出现相同符号。

模块的代码逻辑结构更清晰。

由于使用了模块,因此无需将源代码分为接口和实现部分。

本页共130段,3864个字符,7666 Byte(字节)